<html>

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>
        直播间弹幕监听
    </title>
    <script type="text/javascript" src="decode.js"></script>
    <!--https://raw.githubusercontent.com/google/brotli/master/js/decode.js-->
    <script type="text/javascript" src="jquery-3.7.1.min.js"></script>
    <!--https://code.jquery.com/jquery-3.7.1.min.js-->
    <style>
        body {
            border: 0;
            margin: 0;
        }

        li {
            list-style: none;
            margin: 5px 0;
        }

        #tip {
            color: lightblue;
            font-style: italic;
            font-size: 12px;
            margin: 5px;
            display: block
        }

        #mainul {
            margin: 10px;
            padding: 0px;
        }

        .enter {
            color: gainsboro;
            font-size: 12px;
        }

        /*.text {
            color: black;
        }*/

        .gift {
            color: orange;
            font-size: 12px;
        }
    </style>
    <style>
        .fans-medal-item {
            display: inline-block;
            -webkit-box-sizing: content-box;
            box-sizing: content-box;
            height: 14px;
            line-height: 14px;
            color: #fff;
            border: 1px solid transparent;
            white-space: nowrap;
            border-radius: 2px;
            font-family: "Microsoft YaHei", "Microsoft Sans Serif", "Microsoft SanSerf", "\5FAE\8F6F\96C5\9ED1";
            font-size: 12px;
            position: relative;
            cursor: default;
            vertical-align: middle;
        }

        .fans-medal-item .fans-medal-label,
        .fans-medal-item .fans-medal-level {
            display: block;
            -webkit-box-sizing: content-box;
            box-sizing: content-box;
            height: 100%;
            text-align: center;
            float: left;
        }

        .fans-medal-item .fans-medal-label {
            display: -webkit-box;
            display: -ms-flexbox;
            display: flex;
            -webkit-box-pack: center;
            -ms-flex-pack: center;
            justify-content: center;
            -webkit-box-align: center;
            -ms-flex-align: center;
            align-items: center;
            min-width: 12px;
            padding: 0 4px;
            color: #fff;
            border-top-left-radius: 1px;
            border-bottom-left-radius: 1px;
        }

        .fans-medal-item .fans-medal-label .fans-medal-content {
            display: block;
        }

        .fans-medal-item .fans-medal-level {
            width: 16px;
            background-color: #fff;
            text-align: center;
            color: transparent;
            border-top-left-radius: 1px;
            border-bottom-right-radius: 1px;
        }

        .time {
            color: gainsboro;
            font-size: 12px;
            vertical-align: middle;
        }

        .user-id {
            color: gainsboro;
            font-size: 12px;
            vertical-align: middle;
        }

        .user-name {
            color: #6d757a;
            vertical-align: middle;
        }

        .message {
            color: black;
            vertical-align: middle;
        }
    </style>
</head>

<body>
    <div id="div">
        <a id="tip"> 说明：网址参数 num= 直播间号 & enter= true/false 是否显示进入直播间 & gift = true/false 是否显示礼物</a>
        <ul id="mainul"></ul>
    </div>
</body>
<script>
    var eventTarget = new EventTarget();

    //事件注册
    function on(eventType, callback) {
        eventTarget.addEventListener(eventType, function (e) {
            callback(e.detail);
        });
    }

    //生成认证数据
    function getCertification(json) {
        let encoder = new TextEncoder();    //编码器
        let jsonView = encoder.encode(json);    //utf-8编码
        let buff = new ArrayBuffer(jsonView.byteLength + 16);    //数据包总长度：16位头部长度+bytes长度
        let view = new DataView(buff);    //新建操作视窗
        view.setUint32(0, jsonView.byteLength + 16);    //整个数据包长度
        view.setUint16(4, 16);    //头部长度
        view.setUint16(6, 1);    //协议版本
        view.setUint32(8, 7);    //类型,7为加入房间认证
        view.setUint32(12, 1);    //填1
        for (let r = 0; r < jsonView.byteLength; r++) {
            view.setUint8(16 + r, jsonView[r]);    //填入数据
        }
        return buff;
    }

    //处理服务器发送过来的数据，初步打包
    /*打包格式（JSON）
    键        值类型
    Len        int
    HeadLen     int
    Ver        int
    Type       int
    Num        int
    body       JSON（Type != 3）或者int（Type == 3）
    */
    function handleMessage(blob, call) {
        let reader = new FileReader();
        reader.onload = function (e) {
            let buff = e.target.result;    //ArrayBuffer对象
            let decoder = new TextDecoder();    //解码器
            let view = new DataView(buff);    //视图
            let offset = 0;
            let packet = {};
            let result = [];
            while (offset < buff.byteLength) {    //数据提取
                let packetLen = view.getUint32(offset + 0);
                let headLen = view.getUint16(offset + 4);
                let packetVer = view.getUint16(offset + 6);
                let packetType = view.getUint32(offset + 8);
                let num = view.getUint32(12);
                if (packetVer == 3) {    //解压数据
                    let brArray = new Uint8Array(buff, offset + headLen, packetLen - headLen);
                    let BrotliDecode = makeBrotliDecode();    //生成Brotli格式解压工具的实例
                    let buffFromBr = BrotliDecode(brArray);    //返回Int8Array视图
                    let view = new DataView(buffFromBr.buffer);
                    let offset_Ver3 = 0;
                    while (offset_Ver3 < buffFromBr.byteLength) {    //解压后数据提取
                        let packetLen = view.getUint32(offset_Ver3 + 0);
                        let headLen = view.getUint16(offset_Ver3 + 4);
                        let packetVer = view.getUint16(offset_Ver3 + 6);
                        let packetType = view.getUint32(offset_Ver3 + 8);
                        let num = view.getUint32(12);
                        packet.Len = packetLen;
                        packet.HeadLen = headLen;
                        packet.Ver = packetVer;
                        packet.Type = packetType;
                        packet.Num = num;
                        let dataArray = new Uint8Array(buffFromBr.buffer, offset_Ver3 + headLen, packetLen - headLen);
                        packet.body = decoder.decode(dataArray);    //utf-8格式数据解码，获得字符串
                        result.push(JSON.stringify(packet));    //数据打包后传入数组
                        offset_Ver3 += packetLen;
                    }
                } else {
                    packet.Len = packetLen;
                    packet.HeadLen = headLen;
                    packet.Ver = packetVer;
                    packet.Type = packetType;
                    packet.Num = num;
                    let dataArray = new Uint8Array(buff, offset + headLen, packetLen - headLen);
                    if (packetType == 3) {    //获取人气值
                        packet.body = (new DataView(buff, offset + headLen, packetLen - headLen)).getUint32(0);    //若入参为dataArray.buffer，会返回整段buff的视图，而不是截取后的视图
                    } else {
                        packet.body = decoder.decode(dataArray);    //utf-8格式数据解码，获得字符串
                    }
                    result.push(JSON.stringify(packet));    //数据打包后传入数组
                }
                offset += packetLen;
            }
            call(result);    //数据后续处理
        }
        reader.readAsArrayBuffer(blob);    //读取服务器传来的数据转换为ArrayBuffer
    }

    function webSocket(room_id) {
        if ("WebSocket" in window) {
            console.log("您的浏览器支持WebSocket");
            var timer;
            var ws = new WebSocket("wss://broadcastlv.chat.bilibili.com:443/sub");
            var certification = {
                "uid": 0,
                "roomid": room_id,
                "protover": 3,
                "platform": "web",
                "type": 2,
                "key": ""    //值为空字符串好像也没问题
            }
            ws.onopen = function (e) {
                console.log("open");
                ws.send(getCertification(JSON.stringify(certification)));
                console.log(JSON.stringify(certification))
                //发送心跳包
                timer = setInterval(function () {
                    let buff = new ArrayBuffer(16);
                    let i = new DataView(buff);
                    i.setUint32(0, 0);    //整个封包
                    i.setUint16(4, 16);    //头部
                    i.setUint16(6, 1);    //协议版本
                    i.setUint32(8, 2);    //操作码,2为心跳包
                    i.setUint32(12, 1);    //填1
                    ws.send(buff);
                }, 10 * 1000); //10秒

            }

            ws.onmessage = function (e) {
                //当客户端收到服务端发来的消息时，触发onmessage事件，参数e.data包含server传递过来的数据
                //console.log(e.data);
                let blob = e.data;
                handleMessage(blob, function (result) {
                    //触发事件
                    for (let i = 0; i < result.length; i++) {
                        let json = JSON.parse(result[i]);
                        if (json.Type == 5) {
                            let event = new CustomEvent(JSON.parse(json.body).cmd, { detail: JSON.parse(json.body) });
                            eventTarget.dispatchEvent(event);
                        }
                        if (json.Type == 8) {
                            let event = new CustomEvent("Certify_Success", { detail: JSON.parse(json.body) });
                            eventTarget.dispatchEvent(event);
                        }
                        if (json.Type == 3) {
                            let event = new CustomEvent("VIEW", { detail: json.body });
                            eventTarget.dispatchEvent(event);
                        }
                    }
                });
            }

            ws.onclose = function (e) {
                //当客户端收到服务端发送的关闭连接请求时，触发onclose事件
                console.log("close");
                if (timer != null) {
                    clearInterval(timer);    //停止发送心跳包
                }
                setTimeout(webSocket, 4000);    //4秒后重连
            }

            ws.onerror = function (e) {
                //如果出现连接、处理、接收、发送数据失败的时候触发onerror事件
                console.log(e);
            }

            //每分钟重连
            //setTimeout(function () { ws.send(getCertification(JSON.stringify(certification))); }, 60 * 1000);
            //setInterval(ws.close(), 60*1000);
        } else {
            console.log("您的浏览器不支持WebSocket");
        }
    }


    //认证成功事件
    on("Certify_Success", function (e) {
        let data = e;
        if (data.code == 0) {
            console.log("Certify_Success");
        }
    })
    //高能榜的人员进入直播间
    on("ENTRY_EFFECT", function (e) {
        let data = e;
        let uname = data.data.copy_writing.split("<" + "%")[1].split("%>")[0];
        let timedata = new Date(data.data.trigger_time / 1000000);
        let time = timedata.toLocaleDateString() + " " + timedata.toTimeString().split(" ")[0];
        console.log(data.cmd + " " + time + " " + uname + " 进入直播间");
    })
    //人气值刷新事件
    on("VIEW", function (e) {
        let data = e;
        console.log("VIEW: " + data.toString());
    })

    //进入直播间或关注直播间事件
    on("INTERACT_WORD", function (e) {
        let data = e;
        let uname = data.data.uname;
        let timedata = new Date(data.data.timestamp * 1000);
        let time = timedata.toLocaleDateString() + " " + timedata.toTimeString().split(" ")[0];
        if (data.data.msg_type == 2) {    //个人推测，不一定准确
            console.log(time + " " + uname + " 关注直播间");

        } else {
            if (showEnter) {
                var $last = $("<li class='enter'>" + time + " " + uname + " 进入直播间</li>").appendTo("#mainul");
                if (toBottom) $last[0].scrollIntoView({behavior: 'smooth'});
            }
            console.log(data.cmd + " " + time + " " + uname + " 进入直播间");
        }
    })

    //弹幕事件
    on("DANMU_MSG", function (e) {
        let data = e;
        let uname = data.info[2][1];
        let uid = data.info[2][0];
        let timedata = new Date(data.info[9].ts * 1000);
        let time = timedata.toLocaleDateString() + " " + timedata.toTimeString().split(" ")[0];
        let text = data.info[1];

        var noMedal = false;
        if (data.info[3].length == 0)
            noMedal = true;
        else {
            if (data.info[3][8] == 12632256)
                noMedal = true;
        }
        var medalElement = "";
        if (noMedal == false) {
            let medallevel = data.info[3][0]
            let medalname = data.info[3][1]
            let medalcolor1 = data.info[3][8].toString(16);
            medalcolor1 = (Array(6).join('0') + medalcolor1).slice(-6);
            let medalcolor2 = data.info[3][9].toString(16);
            medalcolor2 = (Array(6).join('0') + medalcolor2).slice(-6);
            medalElement = `<div class="fans-medal-item" style="border-color: #${medalcolor1}">
                <div class="fans-medal-label" style="background-image: linear-gradient(45deg, #${medalcolor1}, #${medalcolor2});">
                    <span class="fans-medal-content">${medalname}</span>
                </div>
                <div class="fans-medal-level" style="color: #${medalcolor1}">${medallevel}</div>
            </div>`
        }
        var $last = $(`<li class='text'>
                            <a class="time">${time}</a>  
                            <div>
                                ${medalElement}
                                <a class="user-name">${uname}</a>
                                <a class="user-id">(${uid}) : </a>
                                <a class="message">${text}</a>
                            </div>
                        </li>`).appendTo("#mainul");
        if (toBottom) $last[0].scrollIntoView({behavior: 'smooth'});

        console.log(data.cmd + " " + time + " " + uname + "：" + text);
    })

    //礼物赠送事件
    on("SEND_GIFT", function (e) {
        let data = e;
        let uname = data.data.uname;
        let gift_num = data.data.num;
        let act = data.data.action;
        let gift_name = data.data.giftName;
        let timedata = new Date(data.data.timestamp * 1000);
        let time = timedata.toLocaleDateString() + " " + timedata.toTimeString().split(" ")[0];
        if (showGift) {
            var $last = $("<li class='gift'>" + time + " " + uname + " :" + act + " " + gift_num + " " + gift_name + "</li>").appendTo("#mainul");
            if (toBottom) $last[0].scrollIntoView({behavior: 'smooth'});
        }
        console.log(data.cmd + " " + time + " " + uname + " :" + act + " " + gift_num + " " + gift_name);
    })

    let searchParams = new URLSearchParams(window.location.search)

    //直播间号码
    var roomnumber = 0;
    roomnumber = parseInt(searchParams.get('num'))
    //显示入场
    var showEnter = false;
    var enterparma = searchParams.get('enter')
    if (enterparma == "true")
        showEnter = true;
    //显示礼物
    var showGift = false;
    var giftparma = searchParams.get('gift')
    if (giftparma == "true")
        showGift = true;

    var $li2 = "<li>正在监听直播间：" + roomnumber + "</li>";
    $("#mainul").append($li2);
    document.title = '直播间 ' + roomnumber + ' 弹幕';
    webSocket(roomnumber);


    var toBottom = true;
    window.addEventListener("wheel", setScroller);//鼠标滚轮
    window.addEventListener("touchmove", setScroller);//移动端滑动
    window.addEventListener("scroll", setScroller);//滚动条点/拖
    function setScroller() {
        if (window.scrollY + window.innerHeight > $("#mainul").height() - 20)
            toBottom = true;
        else
            toBottom = false;
    }
</script>

</html>